
本篇接續前篇 如何製作手風琴 accordion 1【 accordion | 我不會寫 React Component 】
可以先看完上一篇再接續此篇。
部分實作限制,同一時間只能有一個 panel 可以展開。
it(
  "if the implementation allows only one panel to be expanded, " +
    "and if another panel is expanded, collapses that panel.",
  async () => {
    user.setup();
    render(
      <Accordion>
        <Accordion.Item>
          <Accordion.Header>Personal Information</Accordion.Header>
          <Accordion.Panel>test content 1</Accordion.Panel>
        </Accordion.Item>
        <Accordion.Item>
          <Accordion.Header>Billing Address</Accordion.Header>
          <Accordion.Panel>test content 2</Accordion.Panel>
        </Accordion.Item>
        <Accordion.Item>
          <Accordion.Header>Shipping Address</Accordion.Header>
          <Accordion.Panel>test content 3</Accordion.Panel>
        </Accordion.Item>
      </Accordion>
    );
    expect(screen.queryByText("test content 1")).toBeInTheDocument();
    expect(screen.queryByText("test content 2")).not.toBeInTheDocument();
    expect(screen.queryByText("test content 3")).not.toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Billing Address" }));
    expect(screen.queryByText("test content 1")).not.toBeInTheDocument();
    expect(screen.queryByText("test content 2")).toBeInTheDocument();
    expect(screen.queryByText("test content 3")).not.toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Shipping Address" }));
    expect(screen.queryByText("test content 1")).not.toBeInTheDocument();
    expect(screen.queryByText("test content 2")).not.toBeInTheDocument();
    expect(screen.queryByText("test content 3")).toBeInTheDocument();
  }
);
我們需要一個 Context 讓多個 Accordion.Item 共享同一狀態。
因為需求要求一次只能開一個,先用 SingleState 封裝這個邏輯。
interface SingleState {
  expand?: string;
  setExpand: (expand?: string) => void;
}
const Context = createContext<SingleState | null>(null);
透過 id 判斷當前開啟的 panel 是哪個。
但我希望用戶也可以透過 props 自行決定 id,
所以前面會先將 id 全部擷取出來。
useState 預設為第一個 id。
type SingleProps = {
  children?: ReactNode;
};
function Single(props: SingleProps) {
  const id = useId();
  const ids: string[] = [];
  Children.forEach(props.children, (element, index) => {
    if (isValidElement(element) && element.type === Item) {
      ids.push(element.props.id ?? id + index);
    }
  });
  const [expand, _setExpand] = useState<string | undefined>(ids[0]);
  const setExpand = (id?: string) => {
    if (expand !== id) _setExpand(id);
  };
  return (
    <Context.Provider value={{ expand, setExpand }}>
      {Children.map(props.children, (element) => {
        if (isValidElement(element) && element.type === Item) {
          return cloneElement(element, { id: ids.shift(), ...element.props });
        }
        return element;
      })}
    </Context.Provider>
  );
}
Accordion 用戶可以透過 type 決定要起用哪個邏輯,
這裡先只用 Single。
type AccordionProps = {
  type?: "single";
  children?: ReactNode;
};
export function Accordion(props: AccordionProps) {
  return <Single>{props.children}</Single>;
}
我希望 useItemContext 能夠解耦,以便應用在更多情況。
所以調整成 context 由參數拋入。
export function useContextWithError<T>(context: Context<T>, error: string) {
  const _context = useContext(context);
  if (!_context) {
    throw new Error(error);
  }
  return _context;
}
function useItemContext(error: string) {
  return useContextWithError(ItemContext, error);
}
function useContext(error: string) {
  return useContextWithError(Context, error);
}
Item 接上了 context,並將 open 跟 toggle 邏輯改變成
type ItemProps = {
  id?: string;
  children?: ReactNode;
  open?: boolean;
};
function Item(props: ItemProps) {
  const context = useContext(
    `<Accordion.Item /> cannot be rendered outside <Accordion />`
  );
  const id = {
    controls: props.id + "controls",
    labelledby: props.id + "labelledby",
  };
  const open = context.expand === props.id;
  const toggle = () => context.setExpand(props.id);
  return (
    <ItemContext.Provider value={{ open, toggle, id }}>
      {props.children}
    </ItemContext.Provider>
  );
}
當用戶當前對焦在 Accordion 的 header 按鈕上,
按下 Enter 或是 Space 時,
其對應的 panel 也要被展開。
describe("when focus is on the accordion header of a collapsed section, expands the section", () => {
  it("enter", async () => {
    user.setup();
    render(
      <Accordion>
        <Accordion.Item>
          <Accordion.Header>Personal Information</Accordion.Header>
          <Accordion.Panel>test content</Accordion.Panel>
        </Accordion.Item>
        <Accordion.Item>
          <Accordion.Header>Billing Address</Accordion.Header>
          <Accordion.Panel>test content 2</Accordion.Panel>
        </Accordion.Item>
      </Accordion>
    );
    expect(screen.queryByText("test content 2")).not.toBeInTheDocument();
    screen.getByRole("button", { name: "Billing Address" }).focus();
    await user.keyboard("{enter}");
    expect(screen.queryByText("test content 2")).toBeInTheDocument();
  });
  it("space", async () => {
    user.setup();
    render(
      <Accordion>
        <Accordion.Item>
          <Accordion.Header>Personal Information</Accordion.Header>
          <Accordion.Panel>test content</Accordion.Panel>
        </Accordion.Item>
        <Accordion.Item>
          <Accordion.Header>Billing Address</Accordion.Header>
          <Accordion.Panel>test content 2</Accordion.Panel>
        </Accordion.Item>
      </Accordion>
    );
    expect(screen.queryByText("test content 2")).not.toBeInTheDocument();
    screen.getByRole("button", { name: "Billing Address" }).focus();
    await user.keyboard(" ");
    expect(screen.queryByText("test content 2")).toBeInTheDocument();
  });
});
只有焦點是當前元件才需要進行判斷。
瀏覽器預設 Enter 會觸發 onClick,
故這邊只需額外處理 Space。
function Header(props: HeaderProps) {
  const context = useItemContext(
    `<Accordion.Header /> cannot be rendered outside <Accordion />`
  );
  const Comp = props.as ?? "h2";
  const ref = useRef<HTMLButtonElement>(null);
  useEffect(() => {
    const keydown = (event: KeyboardEvent) => {
      if (document.activeElement !== ref.current) return;
      if (event.code === "Space") {
        event.preventDefault();
        context.toggle();
      }
    };
    window.addEventListener("keydown", keydown);
    return () => window.removeEventListener("keydown", keydown);
  }, [context.toggle]);
  return (
    <Comp>
      <button
        ref={ref}
        type="button"
        id={context.id.labelledby}
        aria-expanded={context.open}
        aria-controls={context.id.controls}
        onClick={context.toggle}
      >
        {props.children}
      </button>
    </Comp>
  );
}
部分實作要求一定至少要有一個 panel 是展開的,
透過 Accordion 的 collapse 參數,用戶可以決定 Accordion 是否可以自由收合。
describe(
  "some implementations require one panel to be expanded at all times " +
    "and allow only one panel to be expanded; " +
    "so, they do not support a collapse function.",
  () => {
    it("accordion without `collapse` attribute require one panel expanded at all time", async () => {
      user.setup();
      render(
        <Accordion>
          <Accordion.Item>
            <Accordion.Header>Personal Information</Accordion.Header>
            <Accordion.Panel>test content 1</Accordion.Panel>
          </Accordion.Item>
          <Accordion.Item>
            <Accordion.Header>Billing Address</Accordion.Header>
            <Accordion.Panel>test content 2</Accordion.Panel>
          </Accordion.Item>
        </Accordion>
      );
      expect(screen.queryByText("test content 1")).toBeInTheDocument();
      await user.click(screen.getByText("Personal Information"));
      expect(screen.queryByText("test content 1")).toBeInTheDocument();
      await user.click(screen.getByText("Billing Address"));
      expect(screen.queryByText("test content 1")).not.toBeInTheDocument();
      expect(screen.queryByText("test content 2")).toBeInTheDocument();
      await user.click(screen.getByText("Billing Address"));
      expect(screen.queryByText("test content 1")).not.toBeInTheDocument();
      expect(screen.queryByText("test content 2")).toBeInTheDocument();
    });
    it("accordion with `collapse` attribute can collapse", async () => {
      user.setup();
      render(
        <Accordion collapse>
          <Accordion.Item>
            <Accordion.Header>Personal Information</Accordion.Header>
            <Accordion.Panel>test content 1</Accordion.Panel>
          </Accordion.Item>
          <Accordion.Item>
            <Accordion.Header>Billing Address</Accordion.Header>
            <Accordion.Panel>test content 2</Accordion.Panel>
          </Accordion.Item>
        </Accordion>
      );
      expect(screen.queryByText("test content 1")).toBeInTheDocument();
      await user.click(screen.getByText("Personal Information"));
      expect(screen.queryByText("test content 1")).not.toBeInTheDocument();
      await user.click(screen.getByText("Billing Address"));
      expect(screen.queryByText("test content 2")).toBeInTheDocument();
      await user.click(screen.getByText("Billing Address"));
      expect(screen.queryByText("test content 2")).not.toBeInTheDocument();
    });
  }
);
部分 props 的型別可以共用。
type BaseProps = {
  collapse?: boolean;
  children?: ReactNode;
};
collapse 直接拋入到子元件。
type AccordionProps = BaseProps & {
  type?: "single";
};
export function Accordion(props: AccordionProps) {
  return <Single collapse={props.collapse}>{props.children}</Single>;
}
透過 setExpand 根據邏輯判斷即可。
type SingleProps = BaseProps;
function Single(props: SingleProps) {
  const id = useId();
  const ids: string[] = [];
  Children.forEach(props.children, (element, index) => {
    if (isValidElement(element) && element.type === Item) {
      ids.push(element.props.id ?? id + index);
    }
  });
  const [expand, _setExpand] = useState<string | undefined>(ids[0]);
  const setExpand = (id?: string) => {
    if (props.collapse) {
      return _setExpand(expand === id ? undefined : id);
    }
    if (expand !== id) {
      return _setExpand(id);
    }
  };
  return (
    <Context.Provider value={{ expand, setExpand }}>
      {Children.map(props.children, (element) => {
        if (isValidElement(element) && element.type === Item) {
          return cloneElement(element, { id: ids.shift(), ...element.props });
        }
        return element;
      })}
    </Context.Provider>
  );
}
基於瀏覽器預設的表序列,我們不需要做任何的客製化。
所以這邊僅提供測試。
describe("tab", () => {
  it("moves focus to the next focusable element", async () => {
    user.setup();
    render(
      <Accordion>
        <Accordion.Item>
          <Accordion.Header>Personal Information</Accordion.Header>
          <Accordion.Panel>test content 1</Accordion.Panel>
        </Accordion.Item>
        <Accordion.Item>
          <Accordion.Header>Billing Address</Accordion.Header>
          <Accordion.Panel>test content 2</Accordion.Panel>
        </Accordion.Item>
        <Accordion.Item>
          <Accordion.Header>Shipping Address</Accordion.Header>
          <Accordion.Panel>test content 3</Accordion.Panel>
        </Accordion.Item>
      </Accordion>
    );
    await user.keyboard("{Tab}");
    expect(screen.getByText("Personal Information")).toHaveFocus();
    await user.keyboard("{Tab}");
    expect(screen.getByText("Billing Address")).toHaveFocus();
    await user.keyboard("{Tab}");
    expect(screen.getByText("Shipping Address")).toHaveFocus();
  });
  it("all focusable elements in the accordion are included in the page tab sequence", async () => {
    user.setup();
    render(
      <Accordion>
        <Accordion.Item>
          <Accordion.Header>Personal Information</Accordion.Header>
          <Accordion.Panel>
            <fieldset>
              <p>
                <label htmlFor="cufc1">
                  Name<span aria-hidden="true">*</span>:
                </label>
                <input
                  type="text"
                  name="Name"
                  id="cufc1"
                  aria-required="true"
                />
              </p>
              <p>
                <label htmlFor="cufc2">
                  Email<span aria-hidden="true">*</span>:
                </label>
                <input
                  type="text"
                  name="Email"
                  id="cufc2"
                  aria-required="true"
                />
              </p>
              <p>
                <label htmlFor="cufc3">Phone:</label>
                <input type="text" name="Phone" id="cufc3" />
              </p>
              <p>
                <label htmlFor="cufc4">Extension:</label>
                <input type="text" name="Ext" id="cufc4" />
              </p>
              <p>
                <label htmlFor="cufc5">Country:</label>
                <input type="text" name="Country" id="cufc5" />
              </p>
              <p>
                <label htmlFor="cufc6">City/Province:</label>
                <input type="text" name="City_Province" id="cufc6" />
              </p>
            </fieldset>
          </Accordion.Panel>
        </Accordion.Item>
      </Accordion>
    );
    await user.keyboard("{Tab}");
    expect(screen.getByText("Personal Information")).toHaveFocus();
    await user.keyboard("{Tab}");
    expect(screen.getByLabelText(/Name/)).toHaveFocus();
    await user.keyboard("{Tab}");
    expect(screen.getByLabelText(/Email/)).toHaveFocus();
    await user.keyboard("{Tab}");
    expect(screen.getByLabelText(/Phone/)).toHaveFocus();
    await user.keyboard("{Tab}");
    expect(screen.getByLabelText(/Extension/)).toHaveFocus();
    await user.keyboard("{Tab}");
    expect(screen.getByLabelText(/Country/)).toHaveFocus();
    await user.keyboard("{Tab}");
    expect(screen.getByLabelText(/City\/Province/)).toHaveFocus();
  });
});
describe("shift + tab", () => {
  it("moves focus to the next focusable element", async () => {
    user.setup();
    render(
      <Accordion>
        <Accordion.Item>
          <Accordion.Header>Personal Information</Accordion.Header>
          <Accordion.Panel>test content 1</Accordion.Panel>
        </Accordion.Item>
        <Accordion.Item>
          <Accordion.Header>Billing Address</Accordion.Header>
          <Accordion.Panel>test content 2</Accordion.Panel>
        </Accordion.Item>
        <Accordion.Item>
          <Accordion.Header>Shipping Address</Accordion.Header>
          <Accordion.Panel>test content 3</Accordion.Panel>
        </Accordion.Item>
      </Accordion>
    );
    await user.keyboard("{Shift>}{Tab}{/Shift}");
    expect(screen.getByText("Shipping Address")).toHaveFocus();
    await user.keyboard("{Shift>}{Tab}{/Shift}");
    expect(screen.getByText("Billing Address")).toHaveFocus();
    await user.keyboard("{Shift>}{Tab}{/Shift}");
    expect(screen.getByText("Personal Information")).toHaveFocus();
  });
  it("all focusable elements in the accordion are included in the page tab sequence", async () => {
    user.setup();
    render(
      <Accordion>
        <Accordion.Item>
          <Accordion.Header>Personal Information</Accordion.Header>
          <Accordion.Panel>
            <fieldset>
              <p>
                <label htmlFor="cufc1">
                  Name<span aria-hidden="true">*</span>:
                </label>
                <input
                  type="text"
                  name="Name"
                  id="cufc1"
                  aria-required="true"
                />
              </p>
              <p>
                <label htmlFor="cufc2">
                  Email<span aria-hidden="true">*</span>:
                </label>
                <input
                  type="text"
                  name="Email"
                  id="cufc2"
                  aria-required="true"
                />
              </p>
              <p>
                <label htmlFor="cufc3">Phone:</label>
                <input type="text" name="Phone" id="cufc3" />
              </p>
              <p>
                <label htmlFor="cufc4">Extension:</label>
                <input type="text" name="Ext" id="cufc4" />
              </p>
              <p>
                <label htmlFor="cufc5">Country:</label>
                <input type="text" name="Country" id="cufc5" />
              </p>
              <p>
                <label htmlFor="cufc6">City/Province:</label>
                <input type="text" name="City_Province" id="cufc6" />
              </p>
            </fieldset>
          </Accordion.Panel>
        </Accordion.Item>
      </Accordion>
    );
    await user.keyboard("{Shift>}{Tab}{/Shift}");
    expect(screen.getByLabelText(/City\/Province/)).toHaveFocus();
    await user.keyboard("{Shift>}{Tab}{/Shift}");
    expect(screen.getByLabelText(/Country/)).toHaveFocus();
    await user.keyboard("{Shift>}{Tab}{/Shift}");
    expect(screen.getByLabelText(/Extension/)).toHaveFocus();
    await user.keyboard("{Shift>}{Tab}{/Shift}");
    expect(screen.getByLabelText(/Phone/)).toHaveFocus();
    await user.keyboard("{Shift>}{Tab}{/Shift}");
    expect(screen.getByLabelText(/Email/)).toHaveFocus();
    await user.keyboard("{Shift>}{Tab}{/Shift}");
    expect(screen.getByLabelText(/Name/)).toHaveFocus();
    await user.keyboard("{Shift>}{Tab}{/Shift}");
    expect(screen.getByText("Personal Information")).toHaveFocus();
  });
});